@zvndev/darkmown 2.9.0 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +23 -12
- package/CHANGELOG.md +29 -0
- package/README.md +105 -6
- package/SECURITY.md +1 -0
- package/docs/cli.md +1 -1
- package/docs/spec-alignment.md +16 -2
- package/grammar/wd-directives.gbnf +1 -1
- package/package.json +8 -4
- package/site/pages/benchmarks/index.skin +67 -0
- package/site/pages/benchmarks/index.wd +126 -0
- package/site/pages/docs/index.wd +47 -2
- package/site/pages/index.wd +1 -0
- package/site/pages/playground/index.js +58 -54
- package/src/api-runner.js +80 -8
- package/src/builder.js +70 -15
- package/src/catalog.js +13 -4
- package/src/cli.js +18 -6
- package/src/compiler/body.js +30 -1
- package/src/compiler/context.js +13 -1
- package/src/compiler/includes.js +44 -1
- package/src/compiler/media.js +25 -0
- package/src/compiler/page.js +6 -0
- package/src/compiler/state.js +75 -0
- package/src/compiler/structure.js +272 -4
- package/src/errors.js +74 -3
- package/src/font-subset-worker.js +131 -0
- package/src/font-subset.js +475 -0
- package/src/grammar.js +3 -2
- package/src/headers.js +28 -1
- package/src/image-variant-resize.js +4 -1
- package/src/image-variants.js +9 -4
- package/src/router.js +16 -0
- package/src/skin.js +111 -45
- package/src/statics.js +22 -14
- package/src/templates/dashboard/README.md +3 -1
- package/src/templates/landing/site/pages/index.skin +7 -0
- package/src/templates/landing/site/pages/index.wd +10 -3
- package/src/templates/store/README.md +5 -2
- package/src/templates/store/site/_/card.wd +19 -0
- package/src/templates/store/site/_/products.json +6 -6
- package/src/templates/store/site/pages/index.skin +17 -2
- package/src/templates/store/site/pages/index.wd +3 -12
- package/types/api-runner.d.ts +20 -4
- package/types/builder.d.ts +25 -1
- package/types/catalog.d.ts +3 -2
- package/types/compiler/context.d.ts +28 -0
- package/types/compiler/includes.d.ts +23 -0
- package/types/compiler/structure.d.ts +53 -1
- package/types/errors.d.ts +6 -4
- package/types/font-subset.d.ts +144 -0
- package/types/headers.d.ts +14 -0
- package/types/image-variants.d.ts +7 -3
- package/types/router.d.ts +5 -0
- package/types/skin.d.ts +9 -4
package/AGENTS.md
CHANGED
|
@@ -42,7 +42,7 @@ package.json
|
|
|
42
42
|
- A `page.skin` next to `page.wd` attaches styling; a `page.js` attaches behavior. Matched by basename.
|
|
43
43
|
- **Colocated assets:** any other file under `site/pages/` (not a `.md`/`.wd` route, not a basename-matched `.skin`/`.js`) copies to `dist/` at its own path with the right content-type — so `site/pages/logo.svg` is served at `/logo.svg`. Use the `site/_/` shelf for assets shared across pages; colocate the ones a single page owns.
|
|
44
44
|
- **Responsive image variants:** with sharp installed (`npm i -D sharp`, an optional dependency; builds work without it), the build scans `site/pages` for raster photos (jpg/jpeg/png/webp) over 700px wide and writes resized `name@640.ext` / `name@1280.ext` variants into `dist`, wiring a `srcset` onto the Markdown image with the original kept as `src`. Only widths below the intrinsic size are generated (never upscale), variants keep the source format, and EXIF orientation is applied. **Author-provided `name@<width>.ext` exports win per width**: your file is copied verbatim and measured from its own header (a `@640` file holding 600px emits `600w`), and generation fills only the uncovered widths; other sizes are hand exports on the probe list (64, 96, 128, …, 3840). Skipped silently: images ≤700px, `@width` files, hidden/symlinked paths, svg/gif/avif, and shelf images (the generator covers page-colocated images only). Without sharp the build behaves as before plus one `npm i -D sharp` hint per build; a corrupt image or a sharp failure on one file is a hint naming the file, never a build failure. Resizes cache in `node_modules/.cache/darkmown-images/` (content hash + width + format), so rebuilds and dev reloads do zero sharp work for unchanged images. Never wired: bound srcs (``), remote/`data:`/query-hash srcs, page-relative or extensionless srcs, and raw-HTML `<img>` tags; `sizes` is never emitted (raw `<img srcset sizes>` on an `html: true` page is the way). `.md` pages get the width/height/lazy hardening but no srcset. The first image on the page stays eager with `fetchpriority="high"`, the rest lazy-load, and author-set attributes always win.
|
|
45
|
-
- Shelf `.json` files publish to `/__wd/data/<name>`; every other shelf file publishes to `/__wd/media/<path>`.
|
|
45
|
+
- Shelf `.json` files publish to `/__wd/data/<name>`; every other shelf file publishes to `/__wd/media/<path>`. Hidden segments (`.`, `-`, `_`) and symlinks are never published, matching the page-asset rule; a symlink that stays inside `site/_/` still works as an `@include`/`@loop` source (it unrolls into pages at build time), it is just never copied to `dist`.
|
|
46
46
|
|
|
47
47
|
Commands: `darkmown dev`, `darkmown build [--drafts]`, `darkmown validate` (compile-check without writing `dist`), `darkmown serve` (previews `dist`, runs `api/` functions like dev, and compresses text responses), `darkmown deploy <vercel|cloudflare>`, `darkmown catalog [--llms|--llms-full]`.
|
|
48
48
|
|
|
@@ -78,6 +78,7 @@ rss_limit: 20
|
|
|
78
78
|
- `noindex: true` builds the page but removes it from discovery: a robots `noindex,nofollow` meta, and exclusion from `sitemap.xml`, `rss.xml`, and the generated `llms.txt`/`llms-full.txt`. On a collection entry with a `_schema.wd`, declare it as `noindex: boolean?`.
|
|
79
79
|
- `icon: /path` sets the favicon verbatim (a `type` is added for `.svg`/`.png`/`.ico`). With no `icon:` key, the first of `site/pages/icon.svg`, `icon.png`, `favicon.ico` wins; with neither, the built-in mark is the fallback.
|
|
80
80
|
- `skins: [/base.skin]` (an inline array, same parsing as `tags:`) loads shared `.skin` files for that page only. Paths resolve like `@include` paths; the file must exist, end in `.skin`, and stay inside `site/pages`/`site/_`, or the build fails with `[WD017]`. One scoped skin per page: a shared `scoped` skin scopes to the whole page body.
|
|
81
|
+
- `defaults: [name="value"]` (include files only) sets fallback values for `with` arguments; see Includes & sections.
|
|
81
82
|
- `date` marks the page a post (RSS item, sitemap `lastmod`, `og:type=article`). `updated` and `excerpt` refine that. `author` feeds an article `schema:`.
|
|
82
83
|
- `schema:` emits JSON-LD. One value, or a list. The closed set: `Article`, `BlogPosting`, `TechArticle`, `WebSite`, `Organization`. `organization:` and `logo:` support the `Organization` type. Anything else is a compile error.
|
|
83
84
|
- **Home page only:** `site_url: https://example.com` (no trailing slash) turns on `sitemap.xml`, `rss.xml`, and canonical URLs. `ai_crawlers: allow` (default) or `deny` flips every AI-crawler group in `robots.txt`. `rss_limit: 50` sets how many newest posts `rss.xml` carries (default 20).
|
|
@@ -125,7 +126,7 @@ Pipes also fold inside **raw HTML** on an `html: true` page (attribute values an
|
|
|
125
126
|
|
|
126
127
|
## Directives — the complete set (nothing else exists)
|
|
127
128
|
|
|
128
|
-
Twenty-five directives. `@include`, `@loop`, `:::`, `:if`, `:state`, `:store`, `:computed`, `:fetch`, `:effect`, `:every`, `:theme`, `:button`, `:form`, `:input`, `:textarea`, `:select`, `:checkbox`, `:radio`, `:submit`, `:bind`, `:slider`, `:video`, `:audio`, `:embed`, `:carousel`. Block forms close with `@endloop`, `:endif`, `:endform`, `:endcarousel`, or a bare `:::`. Run `darkmown catalog` for the same list as JSON.
|
|
129
|
+
Twenty-five directives. `@include`, `@loop`, `:::`, `:if`, `:state`, `:store`, `:computed`, `:fetch`, `:effect`, `:every`, `:theme`, `:button`, `:form`, `:input`, `:textarea`, `:select`, `:checkbox`, `:radio`, `:submit`, `:bind`, `:slider`, `:video`, `:audio`, `:embed`, `:carousel`. Block forms close with `@endloop`, `:endif`, `:endform`, `@endinclude`, `:endcarousel`, or a bare `:::`. Run `darkmown catalog` for the same list as JSON.
|
|
129
130
|
|
|
130
131
|
### State, stores, and computed
|
|
131
132
|
```wd
|
|
@@ -141,7 +142,7 @@ Count: { count } · Total: { total }
|
|
|
141
142
|
- `:store name = value` is global by name, shared across pages and tabs. **Persisted by default**; add `ephemeral` to opt out.
|
|
142
143
|
- `:computed name = <expression>` derives from other state: names, numbers, `+ - * /`, comparisons, and the five aggregates `sum(list, field)` / `avg` / `min` / `max` / `count(list)`. It takes no `persist`/`ephemeral` (that is a compile error).
|
|
143
144
|
- Values are JSON: string, number, `true`/`false`/`null`, array, object. An array or object literal may span lines; the `persist`/`ephemeral` token then goes after the closing bracket. A blank line inside the literal ends it.
|
|
144
|
-
- A **seed is stored verbatim**. `:state b = a` where `a` is declared state is `[WD251]`, not a reference: derive it with `:computed b = a`, or quote it to keep the literal word. A plain undeclared word is still fine (`:state status = draft`).
|
|
145
|
+
- A **seed is stored verbatim**. `:state b = a` where `a` is declared state is `[WD251]`, not a reference: derive it with `:computed b = a`, or quote it to keep the literal word. A plain undeclared word is still fine (`:state status = draft`). A seed that looks secret-shaped (a private-key header, a known token prefix with a real body, a JWT, a long mixed-case unbroken token) earns a build hint, never an error: seed `{}` or `""` and fill the value from an `api/` endpoint at runtime.
|
|
145
146
|
- `persist` and `ephemeral` on one line, or the same modifier twice, is `[WD261]`. A declaration named `__proto__`, `constructor`, or `prototype` is `[WD250]` on every declaring directive.
|
|
146
147
|
- A declaration inside an `:if` branch that starts **closed** is hydrated when the branch opens, `persist` included. The seed is claimed once per key, so re-opening the branch does not reset the value.
|
|
147
148
|
|
|
@@ -315,12 +316,22 @@ The closed vocabulary: `string`, `number`, `boolean`, `date`, `string[]`, each w
|
|
|
315
316
|
@include /nav.wd
|
|
316
317
|
@include /card.wd with title="Aurora"
|
|
317
318
|
|
|
319
|
+
@include /card.wd with title="Pricing"
|
|
320
|
+
|
|
321
|
+
Children compile in the caller's scope and land at the include's standalone { children } line.
|
|
322
|
+
|
|
323
|
+
@endinclude
|
|
324
|
+
|
|
318
325
|
::: section #cart .panel
|
|
319
326
|
:state items = []
|
|
320
327
|
{ items.length } items
|
|
321
328
|
:::
|
|
322
329
|
```
|
|
323
|
-
- `@include /partial.wd [with key="value" …]` inlines a file from `site/_/` or `site/pages/`. Paths are **static literals**; there is no dynamic include path. Includes are macros
|
|
330
|
+
- `@include /partial.wd [with key="value" …]` inlines a file from `site/_/` or `site/pages/`. Paths are **static literals**; there is no dynamic include path. Includes are compile-time macros with one slot (below); a missing argument with no default renders `{ title }` literally.
|
|
331
|
+
- **`defaults:` frontmatter** (include files only) sets fallback values for `with` arguments: `defaults: [title="Untitled", cta="Learn more"]`. The caller's `with` value always wins; values are strings (`count="3"` is `"3"`, while a caller's `with count=5` stays typed); entries split on top-level commas only, so `cta="Learn more, today"` works. A malformed `defaults:` is `[WD613]`.
|
|
332
|
+
- **Block form:** `@include /card.wd with title="Pricing"` … `@endinclude` compiles the enclosed lines once in the CALLER's scope (its `:state`, and the loop item when the call sits inside a `@loop`) and inlines them where the include puts a standalone `{ children }` line. Zero runtime cost. Called without a block, the slot renders empty; a block given to an include with no `{ children }` line warns that the content is dropped. `with` arguments resolve at expansion time, so they cannot read a REACTIVE loop's row (`[WD604]`); read the loop variable directly in the children lines, which compile in caller scope.
|
|
333
|
+
- `{ children }` is a standalone line only: mid-paragraph or fenced it stays literal, and on a top-level page it is literal text. A `.md` include cannot host a slot. An include may place `{ children }` more than once; the children still compile once.
|
|
334
|
+
- **Pairing:** `@endinclude` closes the nearest preceding unpaired `@include`, so an inline `@include` between opener and closer consumes the pairing (keep inline includes outside a block's span). One level of block nesting is supported; deeper is `[WD616]`, and a stray closer is `[WD614]`. There is NO unclosed-block error: an unpaired opener is simply the inline form, which must stay legal.
|
|
324
335
|
- `::: [tag] [.class …] [#id] [.class when …] [role="…"] [aria-…="…"] [title="…"] … :::` groups content. A container named `section`, `nav`, or `main` emits that real element; any other name is a `<div>` with that class. State declared inside a section is scoped to it (`sectionId:name`).
|
|
325
336
|
- Container attributes are **exactly three names** (`role=`, any `aria-…=`, `title=`), always double-quoted static text, interleaved freely with `.class`/`#id` tokens: `::: nav .menu role="navigation" aria-label="Main"`. Anything else (`class=`, `id=`, `style=`, `onclick=`, `data-…=`) is `[WD650]`; a missing quoted value is `[WD651]`. No `{ state }` inside an aria value: it emits the literal braces. Zero runtime cost, a static page stays static.
|
|
326
337
|
- There is **no layout or shell inheritance**. Every page includes its own nav and footer.
|
|
@@ -596,7 +607,7 @@ Always include: a tokens block, a real type scale, generous spacing, a `max-widt
|
|
|
596
607
|
|
|
597
608
|
**Syntax highlighting is free.** A fenced code block tagged with a language is highlighted at build time (no client JS), and its token classes read `$code-*` tokens from your skin, so highlighted code follows dark mode with no extra wiring.
|
|
598
609
|
|
|
599
|
-
**`@font-face` and `@keyframes` are skin at-rules.** Their indented children are font descriptors and keyframe selectors, not page selectors, so they compile as self-contained blocks. `@font-face` takes plain declarations (`font-family "Newsreader"`, `src url("/fonts/n.woff2") format("woff2")`, `font-weight 200 800`, `font-display swap`). `@keyframes <name>` requires a plain identifier; each indented child is a keyframe selector (`from`, `to`, a percentage, or a comma list) with its own declarations one level deeper, and anything else is a compile error. Aliases and `$token` refs work inside both, both stay **global** in a `scoped` skin (same contract as `tokens`), and both can nest under an `@media` line (the home for `prefers-reduced-motion` animation guards).
|
|
610
|
+
**`@font-face` and `@keyframes` are skin at-rules.** Their indented children are font descriptors and keyframe selectors, not page selectors, so they compile as self-contained blocks. `@font-face` takes plain declarations (`font-family "Newsreader"`, `src url("/fonts/n.woff2") format("woff2")`, `font-weight 200 800`, `font-display swap`). `@keyframes <name>` requires a plain identifier; each indented child is a keyframe selector (`from`, `to`, a percentage, or a comma list) with its own declarations one level deeper, and anything else is a compile error. Aliases and `$token` refs work inside both, both stay **global** in a `scoped` skin (same contract as `tokens`), and both can nest under an `@media` line (the home for `prefers-reduced-motion` animation guards). With `harfbuzzjs` + `wawoff2` installed, project-local font srcs are also subsetted to woff2 at build time (see the fonts recipe below).
|
|
600
611
|
|
|
601
612
|
**Stylesheet order (changed in 2.8):** include skins compile first, then the page's `skins:` entries in listed order, and the page's own colocated `.skin` LAST, so the page wins ties at equal specificity. Before 2.8 the page's skin came first; delete any specificity hacks that only existed to beat the old order.
|
|
602
613
|
|
|
@@ -660,12 +671,12 @@ Style `summary` as the hamburger and `.menu-drawer[open]` as the drawer in the s
|
|
|
660
671
|
|
|
661
672
|
### Fonts done right
|
|
662
673
|
|
|
663
|
-
Raw TTFs are the most expensive asset a marketing site ships: in the measured rebuild, three variable TTFs cost 1.07 MB on the homepage where the original site served 93 to 158 KB of subset woff2 for the same two families, roughly a 7x difference.
|
|
674
|
+
Raw TTFs are the most expensive asset a marketing site ships: in the measured rebuild, three variable TTFs cost 1.07 MB on the homepage where the original site served 93 to 158 KB of subset woff2 for the same two families, roughly a 7x difference. Two ways to get there:
|
|
675
|
+
|
|
676
|
+
1. **Let the build do it:** `npm i -D harfbuzzjs wawoff2` (both optional dependencies) and a post-pass subsets every project-local `.ttf`/`.otf`/`.woff` in an `@font-face src url(...)` to the site's charset (every code point in the emitted HTML, plus a base set of ASCII, Latin-1, punctuation, arrows, and ★ ✓ ✗), emits `<name>-subset.woff2` beside the original, and rewrites the dist CSS to list the subset first. Cached in `node_modules/.cache/darkmown-fonts/`, idempotent under dev rebuilds, and absent the toolchain you get one hint per build and unchanged output. Measured on the rebuild: 1.07 MB of TTFs became 288 KB of woff2.
|
|
677
|
+
2. Or do it by hand: convert to **woff2**, subset to the glyphs the site uses, and ship the result yourself.
|
|
664
678
|
|
|
665
|
-
|
|
666
|
-
2. Serve it from the shelf (`site/_/fonts/`, published at `/__wd/media/fonts/`) or colocate it under `site/pages/`.
|
|
667
|
-
3. Declare it with `@font-face` in a shared skin, always with `font-display swap`.
|
|
668
|
-
4. Preload the one critical face with a raw `<link rel="preload" as="font" type="font/woff2" crossorigin>` on an `html: true` page, or an HTTP `Link` header on the host.
|
|
679
|
+
Either way: serve fonts from the shelf (`site/_/fonts/`, published at `/__wd/media/fonts/`) or colocate them, declare them with `@font-face` in a shared skin, always with `font-display swap`, and preload the one critical face with a raw `<link rel="preload" as="font" type="font/woff2" crossorigin>` on an `html: true` page, or an HTTP `Link` header on the host.
|
|
669
680
|
|
|
670
681
|
### Pre-fill a form message from query params
|
|
671
682
|
|
|
@@ -701,7 +712,7 @@ Every author-facing compile error opens with a stable `WDxxx` code and names the
|
|
|
701
712
|
|
|
702
713
|
Codes are grouped by subsystem: `WD0xx` source and frontmatter, `WD1xx` loops and collections, `WD2xx` state and expressions, `WD3xx` button/effect/timer actions, `WD4xx` forms, `WD5xx` fetching and URL safety, `WD6xx` includes and page structure, `WD7xx` media, `WD8xx` skins, `WD9xx` project and CLI.
|
|
703
714
|
|
|
704
|
-
The ones you are most likely to hit while writing new pages: `WD191` (a reactive `@loop` over `|` table rows), `WD250` (a reserved declaration name), `WD251` (`:state b = a` where `a` is state), `WD260` (`from-url` on `:store`/`:theme`), `WD261` (`persist` and `ephemeral` together), `WD315` (`:every`/`:effect` inside a reactive loop), `WD450` (a bound field with no state), `WD451` (a bound `:checkbox` with several options), `WD452` (a file field in a GET form), `WD650`/`WD651` (a non-whitelisted or unquoted attribute), `WD950` (a bad `rss_limit`). Every `Use:` hint that contains `[bracket]` placeholders ends with a concrete `— e.g. <valid line>`: **copy the example, never the placeholder.** `darkmown catalog --llms-full` prints every code with its cause and fix.
|
|
715
|
+
The ones you are most likely to hit while writing new pages: `WD191` (a reactive `@loop` over `|` table rows), `WD250` (a reserved declaration name), `WD251` (`:state b = a` where `a` is state), `WD260` (`from-url` on `:store`/`:theme`), `WD261` (`persist` and `ephemeral` together), `WD315` (`:every`/`:effect` inside a reactive loop), `WD450` (a bound field with no state), `WD451` (a bound `:checkbox` with several options), `WD452` (a file field in a GET form), `WD650`/`WD651` (a non-whitelisted or unquoted attribute), `WD804`-`WD807` (a malformed `@font-face` or `@keyframes` block: a stray name on `@font-face`, a missing or invalid `@keyframes` name, a non-keyframe child, or a keyframe with no declarations), `WD950` (a bad `rss_limit`). Every `Use:` hint that contains `[bracket]` placeholders ends with a concrete `— e.g. <valid line>`: **copy the example, never the placeholder.** `darkmown catalog --llms-full` prints every code with its cause and fix.
|
|
705
716
|
|
|
706
717
|
## Hard rules — do not break these
|
|
707
718
|
|
|
@@ -712,7 +723,7 @@ The ones you are most likely to hit while writing new pages: `WD191` (a reactive
|
|
|
712
723
|
5. **Rename = delete.** Upgrading `.md`→`.wd` means removing the `.md`. Never leave both.
|
|
713
724
|
6. **Declare before you bind.** `:state` (or `:form into x`) must appear before any `{ x }` / `:if x` that reads it.
|
|
714
725
|
7. **Whitelisted grammars only.** Button/`:computed`/predicate vocabularies are fixed forms; arbitrary logic goes in a `.js` escape hatch.
|
|
715
|
-
8. **Never write a secret into a page.** `:state`/`:store` seeds are inlined into public HTML. Tokens and keys arrive at runtime from an `api/` endpoint.
|
|
726
|
+
8. **Never write a secret into a page.** `:state`/`:store` seeds are inlined into public HTML. Tokens and keys arrive at runtime from an `api/` endpoint. The compiler backs this rule with a build hint when a seed looks secret-shaped (a hint, never an error).
|
|
716
727
|
|
|
717
728
|
## Minimal complete page
|
|
718
729
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
|
|
4
4
|
|
|
5
|
+
## 2.10.1 (2026-10-14)
|
|
6
|
+
|
|
7
|
+
A patch release of hardening and contract repair: a security pass over everything the build publishes and serves, the skin at-rule errors gaining their registry codes, a compiler hint for secret-shaped state seeds, and a polish sweep over the playground, templates, and VS Code extension. Behavior tightening an author could notice is called out per item.
|
|
8
|
+
|
|
9
|
+
- **SECURITY: font subsetting refuses symlinked fonts.** `resolveFontUrl` now skips symlinks entirely (matching asset publishing and the image pre-pass, which never publish symlinks) and additionally requires canonical realpath containment inside `site/pages`/`site/_`, reusing the `@include` sandbox. A font linked in from outside the project is silently skipped: no subset, no CSS rewrite, no bytes in dist.
|
|
10
|
+
- **SECURITY / AUTHOR-VISIBLE: route names reject whitespace and control characters.** A page filename that would put `\s` or a control character into the route now fails the build with an error naming the file, because route strings are written verbatim into `dist/_headers` and `llms.txt`, where such a character would corrupt the file. A file named `my post.wd` previously routed; it now fails, so rename it. (The throw is deliberately uncoded for now; the router group has `WD908` reserved for it.)
|
|
11
|
+
- **SECURITY: `api/` request bodies are capped at 10 MB** under `darkmown dev` and `darkmown serve`; over cap answers `413 {"ok": false, "error": "Request body too large"}` (at exactly 10 MB the body is still buffered as before). A malformed percent-encoded dynamic segment (`/api/users/%E0%A4%A` against `api/users/[id].js`) now answers `400 {"ok": false, "error": "Malformed URL encoding"}` instead of a 500 with a stack trace.
|
|
12
|
+
- **SECURITY: `darkmown serve` no longer discloses handler error details.** `dev` keeps real error messages in the 500 JSON (and stack traces in its server catch), because the author is the audience on their own machine. `serve` now mirrors production and the generated Cloudflare worker: generic `{"ok": false, "error": "Internal error"}` 500s, with unexpected failures logged server-side only.
|
|
13
|
+
- **SECURITY: the build workers are bounded.** Both spawned workers (image variants, font subsetting) run with a 120 s timeout, and a killed worker reads as the toolchain being unavailable (one hint, no variants or subsets, never a build failure). The image resize worker passes `limitInputPixels: 1e8` (100 MP) to sharp, so a decompression-bomb-shaped image fails as a per-file hint.
|
|
14
|
+
- **Error-contract repair: the skin at-rule errors are coded `WD804`-`WD807`.** The nine `@font-face`/`@keyframes` throw sites (a named `@font-face`, a nested or empty block, a missing or invalid `@keyframes` name, a non-keyframe child, nested declarations, an empty keyframe or selector set) now carry `{ code, file, line, hint, example }` on `err.wd`, with the builder threading the real path and true line numbers (the comment strip in `compileSkin` now preserves newlines; compiled CSS output is unchanged). The new examples are multi-line block constructs, and the generated `docs/errors.md` tables render a multi-line `Use:` example as one code span per line joined by `<br>`, so the rows stay intact. The drift guard also tightened: its scan now matches `return new Error(` as well as `throw new Error(`, so a factory can no longer evade it. `WD801`-`WD803` stay location-less, as before.
|
|
15
|
+
- **Compiler hint: a secret-looking `:state`/`:store` seed is called out at build time.** Seeds are inlined into the page's public HTML by design, so a pasted key publishes to every reader. The hint (never an error, at most once per declaration, string values nested in array/object seeds included) reads: `hint: <file>:<line>: :state "<name>" seed looks like a secret — it will be inlined into public HTML. Seed empty and fill it from an api/ endpoint at runtime.` The heuristic is deliberately tight (private-key headers, known token prefixes with a real token body, JWTs, and long mixed-case unbroken tokens fire; the documented `:store session = {}` pattern, URLs, file paths, kebab slugs, hex hashes, and UUIDs stay quiet). **An author could notice this**: a real key pasted as a seed now prints a hint on every build.
|
|
16
|
+
- **Polish: the playground bundle is freshness-gated.** `scripts/build-playground.mjs --check` byte-compares a fresh in-memory build against the committed bundle (mirroring the runtime-artifact gate), and the check now runs in CI and in the prepublish chain, so the playground can never drift from the compiler it bundles. The bundle was regenerated and compiles the 2.10 features (include blocks, `defaults:`, `{ children }`, `$build.*`).
|
|
17
|
+
- **Polish: templates updated.** `landing` replaces its emoji feature icons with inline SVGs (and carries `html: true` for them); `store` replaces emoji tiles with styled monograms, and its product card is now a real component, `site/_/card.wd` with `defaults: [note="Ships in 2 days"]` and a `{ children }` slot holding the add-to-cart button. Worth knowing when you copy the pattern: `with` arguments resolve at expansion time and cannot see a REACTIVE loop's row (`WD604`), so the card reads the loop variable directly in its children. Both templates' READMEs no longer claim `darkmown serve` is static-only.
|
|
18
|
+
- **Polish: the VS Code extension scopes `@endinclude`** and ships an "Include block (children slot)" snippet (prefix `@include block`). The extension version is unchanged and the new snippet ships with the next publish.
|
|
19
|
+
- **Polish: stale text fixes.** The benchmarks page's fonts bullet now states that 2.10 ships build-time subsetting (with the measured 1.07 MB TTF to 288 KB woff2 figure) instead of calling it a roadmap item, and the spec-alignment audit's current claim reads v2.10.0.
|
|
20
|
+
|
|
21
|
+
## 2.10.0 (2026-10-14)
|
|
22
|
+
|
|
23
|
+
A release about composition and the last of the rebuild's asset costs. Includes grew the two things a component model needs (default arguments and a children slot) without leaving the compile-time macro model: no runtime marker, no `src/runtime.js` change, a static page stays static. Fonts now subset to woff2 at build time, closing the 7x gap the client rebuild measured. And a security pass hardened four publisher gaps, plus the shipped CSP now follows each page's `:embed` hosts. Three new error codes (`WD613`, `WD614`, `WD616`; `WD615` is deliberately unassigned), no breaking change.
|
|
24
|
+
|
|
25
|
+
- **Includes take default arguments and a `{ children }` slot.** An include file declares fallbacks in its own frontmatter (`defaults: [title="Untitled", cta="Learn more"]`); the caller's `with` value always wins, values are strings (a caller's typed `with count=5` still wins as-is), entries split on top-level commas only, and a malformed `defaults:` is `WD613`. The block form, `@include /card.wd with title="Pricing"` … `@endinclude`, compiles the enclosed lines once in the CALLER's scope (its `:state`, and the loop item when the call sits inside a `@loop`, so per-row components stay zero-JS over a static source) and inlines them wherever the include puts a standalone `{ children }` line. Called without a block the slot renders empty; a block given to an include that never uses `{ children }` is dropped with a build warning; `{ children }` mid-paragraph, inside a fence, or on a top-level page stays literal; a `.md` include cannot host a slot. Pairing is pinned: `@endinclude` closes the nearest preceding unpaired `@include`, which means an inline `@include` between opener and closer consumes the pairing (keep inline includes outside a block's span). One level of block nesting is supported (`WD616` deeper), a stray closer is `WD614`, and there is deliberately NO unclosed-block error: an unpaired opener is the inline form, which every pre-feature page relies on, so the two cannot be distinguished without false positives. `WD615` stays unassigned for a future unclosed-detection rule that can be.
|
|
26
|
+
- **Build-time font subsetting to woff2.** With the wasm toolchain installed (`npm i -D harfbuzzjs wawoff2`, both `optionalDependencies`, so `dependencies` stays empty), a post-pass scans `dist/**/*.css` for `@font-face` blocks, subsets every project-local `.ttf`/`.otf`/`.woff` src to the site's charset (every code point in the emitted HTML, numeric character references decoded, plus a base set of ASCII, Latin-1, punctuation, arrows, and ★ ☆ ✓ ✕ ✗ so runtime-injected text stays covered), encodes woff2, emits `<name>-subset.woff2` beside the original, and rewrites the dist CSS to list the subset first. The source `.skin` is never touched, layout features (kern, liga, calt) and variable axes survive, and the rewrite is idempotent so incremental dev builds are safe. **BEHAVIOR CHANGE: a build with the toolchain installed now subsets fonts automatically; without it, nothing changes** except one hint per build naming the fonts that could be subsetted. A font that fails to parse ships as-is with its own hint; the build never fails over a font. Subsets cache in `node_modules/.cache/darkmown-fonts/` keyed by font and charset hashes, and the build summary gains a `fonts (N subsetted, M cached)` clause. The toolchain runs in a `spawnSync` child process (the build is synchronous, the wasm is not), same seam as the image-variant sharp integration. Measured on the 67-route rebuild: 1.07 MB of variable TTFs became 288 KB of subset woff2, 3.7x smaller, with a ~9.6 s first build and ~1.0 s cached rebuilds.
|
|
27
|
+
- **SECURITY / BEHAVIOR CHANGE: shelf assets no longer publish hidden segments or symlinks.** `site/_/` publishing now matches the page-asset rules exactly: a path segment starting with `.`, `-`, or `_` is skipped (previously `site/_/.env.backup` was copied verbatim to `dist/__wd/media/`), and symlinks on the shelf are never followed (previously a symlink copied its target's bytes into `dist`). A symlink that stays inside `site/_/` still works as an `@include`/`@loop` source, so a symlinked `@loop` JSON unrolls into pages at build time but no longer appears at `/__wd/data/<name>`. Both the full build and the incremental dev re-copy enforce this.
|
|
28
|
+
- **SECURITY: `darkmown serve` and `darkmown dev` answer 404 for a URL with a NUL byte.** `%00` used to throw `ERR_INVALID_ARG_VALUE` out of the request handler and kill the server; a decoded NUL is now a plain miss. Traversal containment is unchanged.
|
|
29
|
+
- **SECURITY: the generated Cloudflare worker no longer echoes handler error messages.** A throwing `api/` handler is logged server-side (`console.error`, which lands in the host's logs) and answered `500 { ok: false, error: "Internal error" }` instead of echoing `err.message`, which can carry paths, query text, or connection strings. The dev api-runner keeps the verbose message, because in dev the developer is the audience.
|
|
30
|
+
- **SECURITY: compile-time image dimension reads are sandboxed like `@include`.** `measureImage` now enforces the same lexical plus symlink-canonical containment as `WD601`, so an image src resolving outside `site/pages`/`site/_` (or a symlinked image pointing out) behaves exactly like a missing image: no dimensions, no throw, the file never read.
|
|
31
|
+
- **CSP `frame-src` unions each page's `:embed` hosts per route.** YouTube and Vimeo stay pre-authorized on every page; any other embed host (a NexHealth-style booking iframe, a checkout frame) is now authorized automatically on exactly the pages that embed it, deduped against the base list, with a same-origin embed recording `'self'`. Pages with no extra hosts keep a byte-identical CSP (and a no-embed site's `routes.json` is unchanged). The union ships in Cloudflare's `dist/_headers` and is honored by `darkmown serve`; **on Vercel, widen `frame-src` in your own `vercel.json`**, because Vercel reads it before the build runs, the same pattern as the existing `connect-src` guidance.
|
|
32
|
+
- **Documentation: the site gained a [/benchmarks/](https://darkmown.com/benchmarks/) page** with the measured ToothDocs comparison the 2.8.0 recipes and this release's numbers come from.
|
|
33
|
+
|
|
5
34
|
## 2.9.0 (2026-10-13)
|
|
6
35
|
|
|
7
36
|
- **Authoring/Assets: responsive image variants are generated at build time.** With [sharp](https://sharp.pixelplumbing.com/) installed (`npm i -D sharp`, declared as an `optionalDependency`, so `dependencies` stays empty and every build works without it), the builder scans `site/pages` for raster photos (jpg/jpeg/png/webp) wider than 700px and writes resized `name@640.ext` / `name@1280.ext` siblings into `dist`, handing the compiler a variant map that wires the `srcset` exactly like author-provided variants: the original stays the `src` fallback and joins the srcset at its intrinsic width. Only widths below the intrinsic size are generated (never upscale), variants keep the source format (webp and jpeg at quality 82, jpeg progressive; png stays png at maximum compression), and EXIF orientation is applied before resizing, so rotated phone photos come out upright. **BEHAVIOR CHANGE: a build with sharp installed now generates variants automatically** where before it shipped the original alone; **without sharp, nothing changes** except one hint per build naming the images that could gain variants (`Run npm i -D sharp to generate them at build time.`). The 2.8.0 `@width` convention becomes the override: an author-provided `name@640.ext` is copied verbatim, measured from its own header, and never overwritten, and generation fills only the widths the author left uncovered. Skipped silently: images at or under 700px, `@width` files themselves, hidden and symlinked paths, svg/gif/avif, and shelf images under `site/_`. A corrupt image (dimensions unreadable) or a sharp failure on one file prints a hint naming that file; the build never fails because of an image. Resizes cache in `node_modules/.cache/darkmown-images/` keyed by content hash + width + format (already gitignored with `node_modules`), so a second build does zero sharp work and a fully cached build never loads sharp; dev rebuilds recompute the variant map from the cache, and changing an image forces a full rebuild, exactly as before. The build summary gains an `image variants (N generated, M cached)` clause whenever variants materialized. The build is synchronous and sharp is not, so the resizes run in one child process per build, and a fully cached build spawns nothing.
|
package/README.md
CHANGED
|
@@ -39,8 +39,8 @@ no install and no build step.
|
|
|
39
39
|
|
|
40
40
|
**On this page**
|
|
41
41
|
|
|
42
|
-
- [Quick start](#quick-start) · [Showcase](#showcase) · [Working from this repo](#working-from-this-repo) · [Commands](#commands)
|
|
43
|
-
- **Authoring:** [Authoring model](#authoring-model) · [Directive lines](#directive-lines) · [Interpolation](#interpolation) · [Frontmatter](#frontmatter) · [Static assets](#static-assets) · [SEO & feeds](#seo--feeds-sitemap-rss-robots) · [Loops](#loops) · [Content collections](#content-collections) · [Sections](#sections)
|
|
42
|
+
- [Quick start](#quick-start) · [Showcase](#showcase) · [Benchmarks](#benchmarks) · [Working from this repo](#working-from-this-repo) · [Commands](#commands)
|
|
43
|
+
- **Authoring:** [Authoring model](#authoring-model) · [Directive lines](#directive-lines) · [Includes](#includes) · [Interpolation](#interpolation) · [Frontmatter](#frontmatter) · [Static assets](#static-assets) · [SEO & feeds](#seo--feeds-sitemap-rss-robots) · [Loops](#loops) · [Content collections](#content-collections) · [Sections](#sections)
|
|
44
44
|
- **Reactivity:** [Reactive directives](#reactive-directives) · [Fetching data](#fetching-data) · [Global state](#global-state--store) · [URL as state](#url-as-state-from-url) · [Forms and persistence](#forms-and-persistence) · [Backends & deploy](#backends--deploy) · [Interactions](#interactions--slider-sortable-carousel)
|
|
45
45
|
- **Presentation:** [Inline attributes](#inline-attributes) · [Media](#media--video-audio-embed) · [Syntax highlighting](#syntax-highlighting) · [Dark mode](#dark-mode--tokens-dark) · [Scoped styles](#scoped-styles--scoped) · [Skin at-rules](#skin-at-rules-font-face-and-keyframes)
|
|
46
46
|
- **Extending:** [The escape hatch](#the-escape-hatch) · [Programmatic compile](#programmatic-compile--compilefrommemory) · [Compile error codes](#compile-error-codes) · [AI authoring](#ai-authoring) · [Editor support](#editor-support)
|
|
@@ -58,6 +58,16 @@ Five complete apps, each a readable `.wd` file — see them at **[darkmown.com/s
|
|
|
58
58
|
|
|
59
59
|
Plus focused feature demos: a draggable, keyboard-navigable **[Swiper](https://darkmown.com/carousel/)** (the `wd.subscribe` escape hatch) and a zero-JS **[Media](https://darkmown.com/media/)** page (`:video` / `:audio` / `:embed`).
|
|
60
60
|
|
|
61
|
+
## Benchmarks
|
|
62
|
+
|
|
63
|
+
We rebuilt a live production marketing site (Next.js 16, React 19, Tailwind, on Vercel) in Darkmown: same copy, same images, same CMS content pipeline, 67 routes. Then we measured both sides, median of three loads per route. Full tables, losses included, at **[darkmown.com/benchmarks](https://darkmown.com/benchmarks/)**:
|
|
64
|
+
|
|
65
|
+
- **JavaScript per page:** 225-227 KB compressed across 16 identical chunks on every Next.js page, vs 18-23 KB total in Darkmown (the whole framework runtime is 6.6 KB gzipped).
|
|
66
|
+
- **Requests per page:** 46-70 vs 11-20. The live site also loads ~357 KB of third-party JS (GTM, DoubleClick); the rebuild loads zero.
|
|
67
|
+
- **Build time (same machine):** 16.6 s cold / 23.2 s warm vs 2.7-3.6 s for all 67 routes, with no build cache.
|
|
68
|
+
|
|
69
|
+
The honest losses, because a benchmark that only wins is marketing: the rebuild shipped 1.07 MB of raw TTF fonts against `next/font`'s 158 KB of subset woff2, and 1,045 KB of unoptimized photos against 147 KB of CDN width variants. Fonts now have a documented woff2 + subsetting recipe (2.8), and responsive image variants are generated at build time (2.9).
|
|
70
|
+
|
|
61
71
|
## Working from this repo
|
|
62
72
|
|
|
63
73
|
```sh
|
|
@@ -98,6 +108,79 @@ A directive is a whole line, written flush against the left margin. Four rules c
|
|
|
98
108
|
- **To show a directive name as text, escape it.** `\:fetch` renders the literal text `:fetch`. That is the ordinary CommonMark backslash escape, so it works anywhere Markdown does, including inside a container.
|
|
99
109
|
- **There is no comment syntax.** A trailing `#` or arrow note on a directive line is parsed as part of the value, not as a comment. `html: true ← required` seeds the *string* `true ← required`, which compiles clean while doing the opposite of what it looks like. Put explanations in the prose around the line.
|
|
100
110
|
|
|
111
|
+
## Includes
|
|
112
|
+
|
|
113
|
+
`@include /nav.wd` inlines a file from the `site/_/` shelf (or `site/pages/`); `@include ./-note.wd` resolves beside the current page. Arguments pass Liquid-style: `@include /card.wd with title="Hello" count=3`, and `with title={ feature.title }` passes a value already in scope, including whole objects. Includes inside a loop inherit the loop value automatically. Includes are compile-time macros: they expand at build time, so a static page stays static and there is no runtime marker.
|
|
114
|
+
|
|
115
|
+
### Default values for include arguments
|
|
116
|
+
|
|
117
|
+
An include declares fallbacks for its `with` arguments in its own frontmatter, so every optional argument has a value even when the caller passes nothing:
|
|
118
|
+
|
|
119
|
+
```wd
|
|
120
|
+
---
|
|
121
|
+
defaults: [title="Untitled", cta="Learn more"]
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
::: banner
|
|
125
|
+
## { title }
|
|
126
|
+
|
|
127
|
+
[Read the docs](/docs/) — { cta }
|
|
128
|
+
:::
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- A caller's `with title="…"` always wins; the default fills in only when the caller is silent. An argument with neither a caller value nor a default keeps the old behavior: it renders as literal `{ title }` text.
|
|
132
|
+
- Values are strings, because frontmatter is string-typed: `count="3"` is the string `"3"`, while a caller can still pass a typed `with count=5` and it wins as-is.
|
|
133
|
+
- Entries split on top-level commas only, so a value may contain a comma (`cta="Learn more, today"`); single quotes work too (`cta='Go'`).
|
|
134
|
+
- Defaults scope like `with` arguments: they apply inside the include that declares them, including includes nested inside its body.
|
|
135
|
+
- A malformed `defaults:` (not an inline array, or an entry that is not `name="value"`) is `[WD613]`.
|
|
136
|
+
|
|
137
|
+
### The children slot: `@include … @endinclude`
|
|
138
|
+
|
|
139
|
+
The block form turns an include into a real wrapper component. The lines between the call and `@endinclude` compile once, in the **caller's** context (its scope, its `:state`, and the loop item when the call sits inside a `@loop`), and land wherever the include puts a standalone `{ children }` line:
|
|
140
|
+
|
|
141
|
+
```wd
|
|
142
|
+
@include /card.wd with title="Pricing"
|
|
143
|
+
|
|
144
|
+
Everything in **this paragraph** is the card body.
|
|
145
|
+
|
|
146
|
+
@endinclude
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
with `site/_/card.wd`:
|
|
150
|
+
|
|
151
|
+
```wd
|
|
152
|
+
---
|
|
153
|
+
defaults: [title="Untitled"]
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
::: card
|
|
157
|
+
## { title }
|
|
158
|
+
|
|
159
|
+
{ children }
|
|
160
|
+
:::
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Rules worth knowing:
|
|
164
|
+
|
|
165
|
+
- **Caller scope, build time.** Children lines compile as if written where the call is, so `Price: { product.price | money }` inside a loop's block sees the loop row, and the whole thing stays zero-JS when the source is static. One edge to know: `with` arguments resolve at expansion time, so they cannot read a REACTIVE loop's row (`[WD604]`); a component inside a reactive loop reads the loop variable directly in its children lines instead.
|
|
166
|
+
- **The slot is a standalone line.** `{ children }` written mid-paragraph is literal text, one inside a fenced code block is documentation rather than a slot, and one on a top-level page (not an include) stays literal too. A `.md` include cannot host a slot at all.
|
|
167
|
+
- **Called without a block, the slot renders empty.** An include may place `{ children }` more than once; the children still compile once, so state a child line declares is declared once in the caller.
|
|
168
|
+
- **A block given to an include that never uses `{ children }` is dropped with a build warning**, so a forgotten slot is loud rather than silent.
|
|
169
|
+
- **Pairing: `@endinclude` closes the nearest preceding unpaired `@include`.** The caveat that follows: an inline `@include` written between a block opener and its `@endinclude` is itself the nearest unpaired opener and consumes the closer, so keep inline includes outside a block's span. One level of block nesting is supported (a block inside children, or inside a nested include's body); deeper is `[WD616]`. A closer with no unpaired opener is `[WD614]`.
|
|
170
|
+
- **There is deliberately no "unclosed block" error.** An `@include` that never pairs with an `@endinclude` IS the inline form, and the inline form must stay legal (every pre-feature page is full of `@include /nav.wd` followed by prose, which is syntactically identical to an unclosed block), so the two cannot be distinguished without false positives. Unpaired opener = inline include, unpaired closer = `[WD614]`, a slotted include called without a block renders its slot empty. Treat "close every block" as an authoring rule, not an error.
|
|
171
|
+
|
|
172
|
+
A component per loop row is the payoff pattern, and it stays zero-JS over a static source:
|
|
173
|
+
|
|
174
|
+
```wd
|
|
175
|
+
@loop /products.json into product
|
|
176
|
+
@include /card.wd with title={ product.name }
|
|
177
|
+
|
|
178
|
+
Price: { product.price | money }
|
|
179
|
+
|
|
180
|
+
@endinclude
|
|
181
|
+
@endloop
|
|
182
|
+
```
|
|
183
|
+
|
|
101
184
|
## Interpolation
|
|
102
185
|
|
|
103
186
|
One syntax everywhere: `{ name }` or `{ name.path }`.
|
|
@@ -238,7 +321,7 @@ Reference them with a normal URL: ``. The preview/`
|
|
|
238
321
|
- `site/pages/logo.svg` → `dist/logo.svg` → ``
|
|
239
322
|
- `site/pages/blog/cover.png` → `dist/blog/cover.png` → ``
|
|
240
323
|
|
|
241
|
-
Hidden/private page paths stay private: any `site/pages` asset below a path segment starting with `.`, `-`, or `_` is skipped, and symlinked page assets are never copied. Keep drafts, private notes, and local secrets out of public output by naming them like routes: `.env`, `_private/secret.txt`, or `-draft/notes.txt`.
|
|
324
|
+
Hidden/private page paths stay private: any `site/pages` asset below a path segment starting with `.`, `-`, or `_` is skipped, and symlinked page assets are never copied. Keep drafts, private notes, and local secrets out of public output by naming them like routes: `.env`, `_private/secret.txt`, or `-draft/notes.txt`. The shelf follows the same rule: `site/_/.env.backup` is never published, and a symlink inside `site/_/` is never copied to `dist` (it still works as an `@include`/`@loop` source, unrolling into pages at build time; it just no longer appears as a file at `/__wd/media/` or `/__wd/data/`).
|
|
242
325
|
|
|
243
326
|
Use the shelf for assets shared across many pages; colocate the ones that belong to a single page or section. Both ship untouched with the correct content-type.
|
|
244
327
|
|
|
@@ -882,7 +965,7 @@ The first line renders *Pick a file first.*, not *Error: HTTP 422*. Both keys ar
|
|
|
882
965
|
|
|
883
966
|
`headers=<key>` sends a state object as request headers; pair it with `:store` to keep the token across reloads.
|
|
884
967
|
|
|
885
|
-
**Seed the store empty.** A `:state`/`:store` value is a *build-time* seed: it is inlined into the page's public HTML, so a literal token written into a `.wd` file ships to every visitor. The token has to arrive at runtime, from your own `api/` endpoint:
|
|
968
|
+
**Seed the store empty.** A `:state`/`:store` value is a *build-time* seed: it is inlined into the page's public HTML, so a literal token written into a `.wd` file ships to every visitor. The token has to arrive at runtime, from your own `api/` endpoint. Since 2.10.1 the compiler backs this up with a build hint (never an error) when a seed looks secret-shaped: a private-key header, a known token prefix with a real token body, a JWT, or a long mixed-case unbroken token:
|
|
886
969
|
|
|
887
970
|
```wd
|
|
888
971
|
:store session = {}
|
|
@@ -1200,7 +1283,7 @@ Three one-line directives replace hand-written `<video>` / `<iframe>` markup. Th
|
|
|
1200
1283
|
- **`:video` / `:audio`** compile to a hardened HTML5 player. `preload="metadata"` and `controls` are added by default; flags (`controls`, `autoplay`, `loop`, `muted`, `playsinline`) and attributes (`poster`, `width`, `height`, `preload`) are validated against a per-element whitelist, and `autoplay` silently implies `muted` (browsers block sound-on autoplay). The `src`/`poster` URLs run through the same scheme guard as `:fetch` — relative or `http(s)` only.
|
|
1201
1284
|
- **`:embed`** rewrites a YouTube or Vimeo URL to its **no-cookie / player** form, wraps it in a responsive `16/9` box, and marks the iframe `loading="lazy"` with a locked-down `referrerpolicy`. Any other `http(s)` URL becomes a generic lazy iframe. Add `title="…"` for the accessible name. Three more attributes shape the frame (an embed with none of them emits byte-identical HTML to before): `aspect=4:3` swaps the ratio box for any `<w>:<h>` ratio, `height=400px` pins the frame height and drops the ratio box (units `px`, `rem`, `em`, `vh`, `vw`, `%`; `height` wins when both are given), and `allow="payment"` **replaces** the default iframe permission set with your own tokens from a fixed whitelist: `payment`, `autoplay`, `encrypted-media`, `fullscreen`, `picture-in-picture`, `clipboard-write`, `camera`, `microphone`, `geolocation` (`allow=""` strips all permissions). An unknown attribute is `[WD705]`, an unknown allow token `[WD706]`, and a malformed ratio or length `[WD707]`.
|
|
1202
1285
|
|
|
1203
|
-
Darkmown's shipped CSP pre-authorizes
|
|
1286
|
+
Darkmown's shipped CSP pre-authorizes the two built-in embed origins (`youtube-nocookie.com`, `player.vimeo.com`) on every page, and **unions any other `:embed` host into `frame-src` for exactly the pages that embed it** (a same-origin embed records `'self'`): a NexHealth-style booking iframe works under the shipped headers with no hand-edited policy. The per-route union is emitted in Cloudflare's `dist/_headers` and honored by `darkmown serve`; a page with no extra embed hosts keeps a byte-identical CSP. `media-src 'self' https:` is unchanged, so remote media works out of the box. **Vercel caveat:** Vercel reads `vercel.json` before the build runs, so the generated default carries only the base policy; a project embedding a non-YouTube/Vimeo host on Vercel widens `frame-src` in its own `vercel.json`, the same pattern as `connect-src` for remote `:fetch` targets.
|
|
1204
1287
|
|
|
1205
1288
|
## Syntax highlighting
|
|
1206
1289
|
|
|
@@ -1340,6 +1423,22 @@ A `.skin` already wraps nested rules in `@media` and `@supports`. Two more CSS a
|
|
|
1340
1423
|
|
|
1341
1424
|
Since 2.8 the body parser also tracks raw `<style>` and `<script>` blocks on `html: true` pages, so `@font-face {` or `@media … {` lines inside a raw `<style>` no longer trip the "looks like a directive" hint on every line; the hint still fires for directive-shaped lines in ordinary prose and other raw HTML.
|
|
1342
1425
|
|
|
1426
|
+
### Build-time font subsetting
|
|
1427
|
+
|
|
1428
|
+
Declaring `@font-face` against a project-local `.ttf`/`.otf`/`.woff` gets you the font; with the wasm toolchain installed, the build also shrinks it. After every page and stylesheet is emitted, a post-pass scans `dist/**/*.css` for `@font-face` blocks, subsets each project-local font `src url(...)` to the site's character set, encodes it as woff2, emits `<name>-subset.woff2` beside the original, and rewrites the dist CSS so `src:` lists the subset first with the original as fallback. The source `.skin` is never touched, and every other descriptor (`font-display`, `font-weight` ranges, …) passes through byte-identically. Layout features (kern, liga, calt) and variable-font axes survive subsetting.
|
|
1429
|
+
|
|
1430
|
+
```sh
|
|
1431
|
+
npm i -D harfbuzzjs wawoff2
|
|
1432
|
+
```
|
|
1433
|
+
|
|
1434
|
+
Both are optional dependencies, lazily loaded in a child process behind the same seam as the image-variant sharp integration (the build is synchronous; the wasm toolchain is not, so one `spawnSync` worker per build does the work, and a fully cached build spawns nothing).
|
|
1435
|
+
|
|
1436
|
+
- **The charset is the union of every code point in the emitted HTML** (numeric character references like `★` decoded first) plus a base set: printable ASCII (U+0020–U+007E), the Latin-1 supplement (U+00A0–U+00FF), punctuation U+2010–U+2027 (hyphens, dashes, curly quotes, ellipsis, bullets), primes and per-mille (U+2030–U+2033), single guillemets (U+2039–U+203A), the arrows ← ↑ → ↓ and diagonals (U+2190–U+2199), and ★ ☆ ✓ ✕ ✗. The base set exists so text a colocated `.js` injects at runtime is still covered; non-latin scripts flow in through the page-text union. Known boundary: glyphs used only in CSS `content` are not collected, and the original font stays listed as the fallback, so nothing breaks visually.
|
|
1437
|
+
- **Without the toolchain** the build is unchanged plus one hint per build: `hint: 2 fonts could be subsetted to woff2 (site/_/fonts/a.ttf, site/_/fonts/b.ttf). Run npm i -D harfbuzzjs wawoff2 to subset fonts at build time.` A font that fails to parse or subset gets its own hint and ships as-is; the build never fails over a font.
|
|
1438
|
+
- **Skips, all silent:** remote, protocol-relative, `data:`, and relative `src` URLs; already-`.woff2` sources; paths that escape the project; missing files.
|
|
1439
|
+
- **Cache:** `node_modules/.cache/darkmown-fonts/<fontHash>-<charsetHash>.woff2`, so a second build with unchanged fonts and content does zero wasm work and needs no toolchain for cached entries. The CSS rewrite is idempotent, which is what makes incremental dev builds safe.
|
|
1440
|
+
- The build summary gains a `fonts (3 subsetted, 0 cached)` clause when the post-pass did work. Measured on a 67-route marketing rebuild: three variable TTFs totaling 1.07 MB became 288 KB of subset woff2 (a 3.7x reduction), first build about 9.6 s, cached rebuilds about 1.0 s.
|
|
1441
|
+
|
|
1343
1442
|
## The escape hatch
|
|
1344
1443
|
|
|
1345
1444
|
Reactive pages expose `window.wd` so a colocated `.js` file can do anything the directives deliberately don't — keyboard, drag/touch, canvas, charts, maps:
|
|
@@ -1551,7 +1650,7 @@ Darkmown is small on purpose. Here is what it deliberately refuses and what it g
|
|
|
1551
1650
|
- **Reactive rows cannot fill a Markdown table.** A reactive row is cloned into a `<div>`, which is not a legal child of `<table>`, so `[WD191]` refuses it. Static loops fill tables fine; for reactive rows, build them from containers (`::: trow` / `::: td`) and style them as a table.
|
|
1552
1651
|
- **No i18n beyond `lang:`.** No `hreflang`, no message catalog, no ambient locale, no locale routing. A multilingual site means duplicating the tree per locale.
|
|
1553
1652
|
- **Every framework asset URL is absolute** (`/__wd/...`), so a Darkmown site cannot be mounted at a subpath. It is all-or-nothing per origin.
|
|
1554
|
-
- **Includes are macros, not components.**
|
|
1653
|
+
- **Includes are macros, not components.** One slot (`{ children }`), string-typed `defaults:` fallbacks, and no props interface beyond `with`; a missing argument with no default still renders `{ title }` literally rather than erroring.
|
|
1555
1654
|
- **No `darkmown test`.** There is no built-in way to assert your own site's behavior. Use [`compileFromMemory`](#programmatic-compile--compilefrommemory) against your own pages, or a browser test runner.
|
|
1556
1655
|
|
|
1557
1656
|
**Migrating out is easy, and that is on purpose.** `dist/` is portable static HTML plus one runtime file of ~6.6 KB gzipped. There is no server to port, no proprietary component format, no framework runtime baked through the output, and nothing to un-eject. Point any host at the folder, or hand it to whatever you move to next, and the site keeps working. The lock-in is a directory of Markdown files you already own.
|
package/SECURITY.md
CHANGED
|
@@ -30,6 +30,7 @@ Things Darkmown deliberately guards at compile time and runtime:
|
|
|
30
30
|
- **Private page assets stay private.** Page-colocated assets under `site/pages` are copied only when their relative path has no hidden segment (`.`, `-`, or `_`) and is not a symlink. This keeps `.env`, `_private/*`, `-draft/*`, and symlink-to-outside files out of `dist`.
|
|
31
31
|
- **Output escaping.** Interpolated values are HTML-escaped; state scripts escape `<` to prevent script-tag breakout.
|
|
32
32
|
- **Static server path containment.** The dev/preview servers resolve requests strictly inside `dist`.
|
|
33
|
+
- **Build caches are trusted input.** The `node_modules/.cache/darkmown-*` directories (image variants, font subsets) are copied into `dist` verbatim when their content-hash keys match, so a poisoned shared CI cache ships attacker bytes. Pin and verify cache artifacts in CI, or delete the directories to rebuild from source.
|
|
33
34
|
|
|
34
35
|
## The #1 footgun: the raw HTML opt-in (`html: true`)
|
|
35
36
|
|
package/docs/cli.md
CHANGED
|
@@ -112,7 +112,7 @@ Serves the already-built `dist` directory for local preview. Run `darkmown build
|
|
|
112
112
|
|
|
113
113
|
Since 2.8 the preview is at parity with dev for two things:
|
|
114
114
|
|
|
115
|
-
- **`api/` functions run.** A request to `/api/*` that matches a handler under the project's top-level `api/` directory executes it (same contract and file routing as `darkmown dev` and the production hosts: `api/echo.js` → `/api/echo`, `api/users/[id].js` → `/api/users/:id`); anything else falls through to static serving. `:form action="/api/…"` posts and `:fetch … from "/api/…"` demos that worked in dev now work in the preview too, and editing a handler is picked up without a restart. A project with no `api/` directory behaves exactly as before.
|
|
115
|
+
- **`api/` functions run.** A request to `/api/*` that matches a handler under the project's top-level `api/` directory executes it (same contract and file routing as `darkmown dev` and the production hosts: `api/echo.js` → `/api/echo`, `api/users/[id].js` → `/api/users/:id`); anything else falls through to static serving. `:form action="/api/…"` posts and `:fetch … from "/api/…"` demos that worked in dev now work in the preview too, and editing a handler is picked up without a restart. A project with no `api/` directory behaves exactly as before. Since 2.10.1 the local runner matches production hygiene: request bodies are capped at 10 MB (`413` over cap), a malformed percent-encoded dynamic segment answers `400`, and under `serve` a throwing handler answers the generic `{"ok": false, "error": "Internal error"}` while the real error is logged server-side only. `darkmown dev` keeps the verbose message, because in dev the developer is the audience.
|
|
116
116
|
- **Text responses are compressed.** The server negotiates off the request's `Accept-Encoding` (brotli preferred, then gzip, q-values honored) and sets `Vary: Accept-Encoding`, appending to an existing `Vary` rather than clobbering it. Compressible: `text/*`, `application/javascript`, `application/json`, `application/xml`, and `image/svg+xml`, across static files, the built 404 page, and `api/` responses (a handler that set its own `Content-Encoding` is never double-compressed). Images, video, audio, and fonts are never compressed. `darkmown dev` is intentionally uncompressed, byte-identical to 2.7.
|
|
117
117
|
|
|
118
118
|
### `darkmown catalog [--llms|--llms-full]`
|
package/docs/spec-alignment.md
CHANGED
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
This audit compares the current Darkmown implementation with the original MARROW/WhateverDown vision and the follow-up decisions made on 2026-06-11.
|
|
4
4
|
|
|
5
|
-
## 2.
|
|
5
|
+
## 2.10.1 additions: hardening and contract repair (2026-10-14)
|
|
6
|
+
|
|
7
|
+
A patch release: nothing in it moves the authoring surface, it tightens what the build publishes and repairs the error contract the 2.8.0 skin at-rules shipped without.
|
|
8
|
+
|
|
9
|
+
- **The security pass tightened everything the build publishes or serves.** Font subsetting refuses symlinked fonts through the shared `@include` sandbox, route names reject whitespace and control characters before they can corrupt `dist/_headers`, local `api/` bodies cap at 10 MB (413) with a 400 for malformed encoding, `darkmown serve` mirrors production's generic 500s (dev keeps verbose errors for its real audience), and both build workers run bounded (120 s timeouts; sharp caps input pixels at 100 MP).
|
|
10
|
+
- **The skin at-rule errors joined the registry as WD804-WD807**, with true file:line threaded from the builder and multi-line examples rendering in the generated docs table. The drift guard now also catches `return new Error(` factories, so the coded-error contract cannot be quietly evaded again.
|
|
11
|
+
- **A compiler hint nudges secret-shaped `:state`/`:store` seeds** toward the documented seed-empty pattern (a hint, never an error, with a deliberately tight heuristic that ignores the documented auth pattern, URLs, hashes, and UUIDs), and a polish sweep gated playground-bundle freshness in CI and rebuilt the templates on the 2.10 component model: the store card is a real `defaults:` + `{ children }` component, which is now the reference implementation of that feature.
|
|
12
|
+
|
|
13
|
+
## 2.10.0 additions: compile-time components, subset fonts, hardening (2026-10-14)
|
|
14
|
+
|
|
15
|
+
- **Includes became components without leaving the macro model.** `defaults:` frontmatter gives an include's `with` arguments string fallbacks (`WD613` on a malformed array), and the `@include … @endinclude` block form compiles the enclosed lines once in the caller's scope and inlines them at the include's standalone `{ children }` line: the card/banner wrapper the vision always implied, at zero runtime cost. The pairing decision is deliberate and documented: `@endinclude` closes the nearest unpaired opener (an inline include in between consumes it), one level of block nesting is supported (`WD616` deeper), a stray closer is `WD614`, and there is NO unclosed-block error, because an unpaired opener is the inline form and every pre-feature page relies on it. `WD615` stays unassigned for a future unclosed-detection rule that avoids those false positives.
|
|
16
|
+
- **Fonts subset at build time as a dist-CSS post-pass, not a `.skin` feature.** The builder scans the emitted CSS for `@font-face` srcs, subsets project-local fonts with harfbuzz's wasm and encodes woff2 with wawoff2 (both optionalDependencies, run in a `spawnSync` worker because the build is synchronous and the toolchain is not), and rewrites the dist `src:` to list the subset first while the source `.skin` stays untouched. The charset is the union of every emitted HTML code point plus a base punctuation/symbol set, so runtime-injected text stays covered. Without the toolchain the build is 2.9.0-identical plus one hint. Measured 3.7x on the rebuild's three variable fonts.
|
|
17
|
+
- **Security hardening closed four publisher gaps, and CSP now follows the embed.** Shelf assets no longer publish hidden segments or symlinks (an in-sandbox symlink still works as an `@include`/`@loop` source; it is just never copied), NUL-byte URLs answer 404 instead of killing the preview server, the generated Cloudflare worker returns a generic 500 while logging the real error server-side, and compile-time image measurement enforces the `@include` sandbox. The user-visible one: `frame-src` unions each route's `:embed` hosts, so a third-party booking or checkout iframe works under the shipped headers on Cloudflare and `darkmown serve` (Vercel needs a `vercel.json` widening, because Vercel reads it before the build).
|
|
18
|
+
|
|
19
|
+
## 2.9.0 additions: build-time image variants (2026-10-13)
|
|
6
20
|
|
|
7
21
|
- **The framework now makes the image variants itself, closing the rebuild's largest asset gap.** With the optional sharp dependency installed, `darkmown build` resizes page-colocated raster photos over 700px into 640/1280 `name@width.ext` siblings (never upscaling, EXIF-aware, cached by content hash under `node_modules/.cache/darkmown-images/` so rebuilds and dev reloads do no sharp work), the 2.8.0 author-provided `@width` convention stays the per-width override, and a build without sharp behaves exactly like 2.8.0 plus one hint.
|
|
8
22
|
|
|
@@ -300,4 +314,4 @@ The compiler recognizes one directive, `:try`, that exists **only to power the d
|
|
|
300
314
|
|
|
301
315
|
Darkmown is faithful to the core thesis: Markdown remains the authoring center, static pages ship zero framework JavaScript, and reactive behavior compiles into tiny direct-DOM islands only where declared. The extension is the feature gate, the loop is one concept with progressive disclosure, and state scopes to document structure.
|
|
302
316
|
|
|
303
|
-
As of **v2.
|
|
317
|
+
As of **v2.10.0** the public npm package is published as `@zvndev/darkmown`, the project is MIT licensed, and the directive surface is stable at the 25 directives `darkmown catalog` prints. The releases that moved the surface are each recorded above: **2.0.0** made the one breaking change, escaping raw HTML in markdown by default (`html: false`), with `html: true` as the per-page opt-in, a security default flip rather than a directive change. **2.1.0** replaced the runtime's `new Function` with a closed interpreter over a validated expression AST, so reactive pages now run under the same strict, `'unsafe-eval'`-free CSP as static ones. **2.5.0** added the agent tool surface (`@zvndev/darkmown/tools`). **2.8.0** widened everything around the directive core (`skins:` shared skins, `@font-face`/`@keyframes` in `.skin`, `$build` bindings, format pipes in raw HTML) without adding a directive, **2.9.0** moved responsive image variants into the build, and **2.10.0** grew includes into the component model (`defaults:` arguments and the `@include` … `@endinclude` block form with a `{ children }` slot) plus build-time font subsetting, again with no new directive and no runtime change. The remaining items above stay scoped as post-launch product and distribution work rather than missing core claims.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
root ::= directive | closer | binding
|
|
5
5
|
directive ::= dir-include | dir-loop | dir-container | dir-if | dir-state | dir-store | dir-computed | dir-fetch | dir-effect | dir-every | dir-theme | dir-button | dir-form | dir-input | dir-textarea | dir-select | dir-checkbox | dir-radio | dir-submit | dir-bind | dir-slider | dir-video | dir-audio | dir-embed | dir-carousel
|
|
6
|
-
closer ::= "@endloop" | "@empty" | ":endif" | ":endform" | ":endcarousel" | ":::" | ":else if" sp condition ( sp joiner sp condition )* | ":else if" sp path | ":else"
|
|
6
|
+
closer ::= "@endloop" | "@empty" | "@endinclude" | ":endif" | ":endform" | ":endcarousel" | ":::" | ":else if" sp condition ( sp joiner sp condition )* | ":else if" sp path | ":else"
|
|
7
7
|
|
|
8
8
|
dir-loop ::= "@loop" sp operand sp "into" sp ident loop-clauses
|
|
9
9
|
dir-if ::= ":if" sp ( condition (sp joiner sp condition)* | path )
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"format:check": "biome format .",
|
|
50
50
|
"size:check": "node scripts/size-check.mjs",
|
|
51
51
|
"prepare": "husky || true",
|
|
52
|
-
"prepublish:check": "npm run typecheck && node scripts/build-runtime.mjs --check && npm run test:cov && npm run build && npm run runtime:size && npm audit --audit-level=high && npm run smoke",
|
|
52
|
+
"prepublish:check": "npm run typecheck && node scripts/build-runtime.mjs --check && node scripts/build-playground.mjs --check && npm run test:cov && npm run build && npm run runtime:size && npm audit --audit-level=high && npm run smoke",
|
|
53
53
|
"prepublishOnly": "npm run prepublish:check",
|
|
54
54
|
"og": "node scripts/og-image.mjs",
|
|
55
55
|
"bench:build": "node scripts/bench-build.mjs"
|
|
@@ -86,10 +86,12 @@
|
|
|
86
86
|
"@playwright/test": "^1.61.1",
|
|
87
87
|
"@types/node": "^26.1.1",
|
|
88
88
|
"esbuild": "0.28.2",
|
|
89
|
+
"harfbuzzjs": "^1.6.3",
|
|
89
90
|
"husky": "^9.1.7",
|
|
90
91
|
"lint-staged": "^17.0.0",
|
|
91
92
|
"sharp": "^0.35.5",
|
|
92
|
-
"typescript": "^6.0.3"
|
|
93
|
+
"typescript": "^6.0.3",
|
|
94
|
+
"wawoff2": "^2.0.1"
|
|
93
95
|
},
|
|
94
96
|
"repository": {
|
|
95
97
|
"type": "git",
|
|
@@ -110,6 +112,8 @@
|
|
|
110
112
|
],
|
|
111
113
|
"license": "MIT",
|
|
112
114
|
"optionalDependencies": {
|
|
113
|
-
"
|
|
115
|
+
"harfbuzzjs": "^1.6.3",
|
|
116
|
+
"sharp": "^0.35.5",
|
|
117
|
+
"wawoff2": "^2.0.1"
|
|
114
118
|
}
|
|
115
119
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* The benchmarks page: measured numbers from rebuilding a production
|
|
2
|
+
Next.js marketing site in Darkmown. Layers on /base.wd. Class names
|
|
3
|
+
match the `::: name .class` containers in index.wd. The page contains
|
|
4
|
+
no directives and no raw HTML, so it ships zero JavaScript. */
|
|
5
|
+
|
|
6
|
+
.stats
|
|
7
|
+
display grid
|
|
8
|
+
grid-template-columns repeat(4, 1fr)
|
|
9
|
+
gap 1rem
|
|
10
|
+
margin 1.75rem 0 2.25rem
|
|
11
|
+
padding 1.5rem 0
|
|
12
|
+
border-top 1px solid $line-soft
|
|
13
|
+
border-bottom 1px solid $line-soft
|
|
14
|
+
|
|
15
|
+
/* The value line and its label must stay separate containers, otherwise
|
|
16
|
+
Markdown folds them into one paragraph (same trick as the homepage). */
|
|
17
|
+
.stat
|
|
18
|
+
display flex
|
|
19
|
+
flex-direction column
|
|
20
|
+
gap .25rem
|
|
21
|
+
min-width 0
|
|
22
|
+
|
|
23
|
+
.stat > p
|
|
24
|
+
margin 0
|
|
25
|
+
font-size 2rem
|
|
26
|
+
line-height 1.05
|
|
27
|
+
color $accent
|
|
28
|
+
letter-spacing -.02em
|
|
29
|
+
font-variant-numeric tabular-nums
|
|
30
|
+
|
|
31
|
+
.statlabel
|
|
32
|
+
font-size .85rem
|
|
33
|
+
line-height 1.4
|
|
34
|
+
color $ink-soft
|
|
35
|
+
|
|
36
|
+
.statlabel p
|
|
37
|
+
margin 0
|
|
38
|
+
|
|
39
|
+
/* The route-by-route table is wide; let it scroll sideways on a phone
|
|
40
|
+
instead of crushing the columns. */
|
|
41
|
+
.tablewrap
|
|
42
|
+
overflow-x auto
|
|
43
|
+
margin 1.5rem 0
|
|
44
|
+
|
|
45
|
+
.tablewrap table
|
|
46
|
+
margin 0
|
|
47
|
+
min-width 720px
|
|
48
|
+
font-size .88rem
|
|
49
|
+
|
|
50
|
+
.tablewrap th, .tablewrap td
|
|
51
|
+
white-space nowrap
|
|
52
|
+
|
|
53
|
+
/* Numeric columns read better right-aligned and tabular. */
|
|
54
|
+
.tablewrap th:nth-child(n+2), .tablewrap td:nth-child(n+2)
|
|
55
|
+
text-align right
|
|
56
|
+
font-variant-numeric tabular-nums
|
|
57
|
+
|
|
58
|
+
.bench-cta
|
|
59
|
+
display flex
|
|
60
|
+
flex-wrap wrap
|
|
61
|
+
gap .6rem
|
|
62
|
+
margin 2.5rem 0 0
|
|
63
|
+
|
|
64
|
+
@media (max-width: 760px)
|
|
65
|
+
.stats
|
|
66
|
+
grid-template-columns 1fr 1fr
|
|
67
|
+
gap 1.25rem
|